L.Proj = {};

L.Proj._isProj4Proj = function(a) {
  return typeof a['projName'] !== 'undefined';
}

L.Proj.Projection = L.Class.extend({
  initialize: function(a, def) {
    if (L.Proj._isProj4Proj(a)) {
      this._proj = a;
    } else {
      var code = a;
      Proj4js.defs[code] = def;
      this._proj = new Proj4js.Proj(code);
    }
  },

  project: function (latlng) {
    var point = new L.Point(latlng.lng, latlng.lat);
    return Proj4js.transform(Proj4js.WGS84, this._proj, point);
  },

  unproject: function (point, unbounded) {
    var point2 = Proj4js.transform(this._proj, Proj4js.WGS84, point.clone());
    return new L.LatLng(point2.y, point2.x, unbounded);
  }
});

L.Proj.CRS = L.Class.extend({
  includes: L.CRS,

  options: {
    transformation: new L.Transformation(1, 0, -1, 0)
  },

  initialize: function(a, b, c) {
    var code, proj, def, options;

    if (L.Proj._isProj4Proj(a)) {
      proj = a;
      code = proj.srsCode;
      options = b || {};

      this.projection = new L.Proj.Projection(proj);
    } else {
      code = a;
      def = b;
      options = c || {};
      this.projection = new L.Proj.Projection(code, def);
    }

    L.Util.setOptions(this, options);
    this.code = code;
    this.transformation = this.options.transformation;

    if (this.options.origin) {
      this.transformation =
        new L.Transformation(1, -this.options.origin[0],
          -1, this.options.origin[1]);
    }

    if (this.options.scales) {
      this.scale = function(zoom) {
        return this.options.scales[zoom];
      }
    } else if (this.options.resolutions) {
      this.scale = function(zoom) {
        return 1 / this.options.resolutions[zoom];
      }
    }
  }
});

L.Proj.CRS.TMS = L.Proj.CRS.extend({
  initialize: function(a, b, c, d) {
    if (L.Proj._isProj4Proj(a)) {
      var proj = a,
        projectedBounds = b,
        options = c || {};
      options.origin = [projectedBounds[0], projectedBounds[3]];
      L.Proj.CRS.prototype.initialize(proj, options);
    } else {
      var code = a,
        def = b,
        projectedBounds = c,
        options = d || {};
      options.origin = [projectedBounds[0], projectedBounds[3]];
      L.Proj.CRS.prototype.initialize(code, def, options);
    }

    this.projectedBounds = projectedBounds;
  }
});

L.Proj.TileLayer = {};

L.Proj.TileLayer.TMS = L.TileLayer.extend({
  options: {
    tms: true,
    continuousWorld: true
  },

  initialize: function(urlTemplate, crs, options) {
    if (!(crs instanceof L.Proj.CRS.TMS)) {
      throw new Error("CRS is not L.Proj.CRS.TMS.");
    }

    L.TileLayer.prototype.initialize.call(this, urlTemplate, options);
    this.crs = crs;

    // Verify grid alignment
    for (var i = this.options.minZoom; i < this.options.maxZoom; i++) {
      var gridHeight = (this.crs.projectedBounds[3] - this.crs.projectedBounds[1]) /
        this._projectedTileSize(i);
      if (Math.abs(gridHeight - Math.round(gridHeight)) > 1e-3) {
        throw new Error("Projected bounds does not match grid at zoom " + i);
      }
    }
  },

  getTileUrl: function(tilePoint) {
    var gridHeight =
      Math.round((this.crs.projectedBounds[3] - this.crs.projectedBounds[1]) /
      this._projectedTileSize(this._map.getZoom()));

    // TODO: relies on some of TileLayer's internals
    return L.Util.template(this._url, L.Util.extend({
      s: this._getSubdomain(tilePoint),
      z: this._getZoomForUrl(),
      x: tilePoint.x,
      y: gridHeight - tilePoint.y - 1
    }, this.options));
  },

  _projectedTileSize: function(zoom) {
    return (this.options.tileSize / this.crs.scale(zoom));
  }
});

if (typeof module !== 'undefined') module.exports = L.Proj;

if (typeof L !== 'undefined' && typeof L.CRS !== 'undefined') {
  // This is left here for backwards compatibility
  L.CRS.proj4js = (function () {
    return function (code, def, transformation, options) {
      options = options || {};
      if (transformation) options.transformation = transformation;

      return new L.Proj.CRS(code, def, options);
    };
  }());
}